import os
import sys
import subprocess
import shutil
from pathlib import Path, PurePosixPath

from click import ClickException
from jinja2 import Environment, PackageLoader, StrictUndefined

from ploomber.io import TerminalWriter


def to_pascal_case(name):
    return "".join([w.capitalize() for w in name.split("_")])


def _delete(dst):
    dst = Path(dst)

    if dst.is_file():
        dst.unlink()

    if dst.is_dir():
        shutil.rmtree(dst)


class CommanderException(ClickException):
    """
    Exception raised when the workflow cannot proceed and require a fix
    from the user. It is a subclass of ClickException, which signals the CLI
    to hide the traceback
    """

    pass


class CommanderStop(Exception):
    """
    An exception that stops the execution of a commander without raising
    an exception
    """

    pass


class Commander:
    """Manage script workflows"""

    def __init__(self, workspace=None, templates_path=None, environment_kwargs=None):
        self.tw = TerminalWriter()
        self.workspace = None if not workspace else Path(workspace).resolve()
        self._to_delete = []
        self._warnings = []

        self._wd = Path(".").resolve()

        if templates_path:
            self._env = Environment(
                loader=PackageLoader(*templates_path),
                undefined=StrictUndefined,
                **(environment_kwargs or {}),
            )
            self._env.filters["to_pascal_case"] = to_pascal_case
        else:
            self._env = None

    def run(
        self,
        *cmd,
        description=None,
        capture_output=False,
        expected_output=None,
        error_message=None,
        hint=None,
        show_cmd=True,
    ):
        """Execute a command in a subprocess

        Parameters
        ----------
        *cmd
            Command to execute

        description: str, default=None
            Label to display before executing the command

        capture_output: bool, default=False
            Captures output, otherwise prints to standard output and standard
            error

        expected_output: str, default=None
            Raises a RuntimeError if the output is different than this value.
            Only valid when capture_output=True

        error_message: str, default=None
            Error to display when expected_output does not match. If None,
            a generic message is shown

        hint: str, default=None
            An optional string to show when at the end of the error when
            the expected_output does not match. Used to hint the user how
            to fix the problem

        show_cmd : bool, default=True
            Whether to display the command next to the description
            (and error message if it fails) or not. Only valid when
            description is not None
        """
        cmd_str = " ".join(cmd)

        if expected_output is not None and not capture_output:
            raise RuntimeError(
                "capture_output must be True when " "expected_output is not None"
            )

        if description:
            header = f"{description}: {cmd_str}" if show_cmd else description
            self.tw.sep("=", header, blue=True)

        error = None
        result = None

        try:
            result = subprocess.run(cmd, capture_output=capture_output)
            # throw error if return code is not 0
            result.check_returncode()
        except Exception as e:
            error = e
        else:
            # result is None if run is run with capture_output=false
            if result.stdout is not None:
                result = result.stdout.decode(sys.stdout.encoding)

        if expected_output is not None:
            error = result != expected_output

        if error:
            lines = []

            if error_message:
                line_first = error_message
            else:
                if show_cmd:
                    cmd_str = " ".join(cmd)
                    line_first = (
                        "An error occurred when executing " f"command: {cmd_str}"
                    )
                else:
                    line_first = "An error occurred."

            lines.append(line_first)

            if not capture_output:
                lines.append(f"Original error message: {error}")

            if hint:
                lines.append(f"Hint: {hint}.")

            raise CommanderException("\n".join(lines))
        else:
            return result

    def __enter__(self):
        if self.workspace and not Path(self.workspace).exists():
            Path(self.workspace).mkdir()

        return self

    def __exit__(self, exc_type, exc_value, traceback):
        # move to the original working directory
        os.chdir(self._wd)

        self.rm(*self._to_delete)
        supress = isinstance(exc_value, CommanderStop)

        if supress:
            self.info(str(exc_value))

        self._warn_show()

        return supress

    def rm(self, *args):
        """Deletes all files/directories

        Examples
        --------
        >>> cmdr.rm('file', 'directory') # doctest: +SKIP
        """
        for f in args:
            _delete(f)

    def rm_on_exit(self, path):
        """Removes file upon exit

        Examples
        --------
        >>> cmdr.rm_on_exit('some_temporary_file') # doctest: +SKIP
        """
        self._to_delete.append(Path(path).resolve())

    def copy_template(self, path, **render_kwargs):
        """Copy template to the workspace

        Parameters
        ----------
        path : str
            Path to template (relative to templates path)

        **render_kwargs
            Keyword arguments passed to the template

        Examples
        --------
        >>> # copies template in {templates-path}/directory/template.yaml
        >>> # to {workspace}/template.yaml
        >>> cmdr.copy_template('directory/template.yaml') # doctest: +SKIP
        """
        dst = Path(self.workspace, PurePosixPath(path).name)

        # This message is no longer valid since this is only called
        # when there is no env yet
        if dst.exists():
            self.success(f"Using existing {path!s}...")
        else:
            self.info(f"Adding {dst!s}...")
            dst.parent.mkdir(exist_ok=True, parents=True)
            content = self._env.get_template(str(path)).render(**render_kwargs)
            dst.write_text(content)

    def cd(self, dir_):
        """Change current working directory"""
        os.chdir(dir_)

    def cp(self, src):
        """
        Copies a file or directory to the workspace, replacing it if necessary.
        Deleted on exit.

        Notes
        -----
        Used mainly for preparing Dockerfiles since they can only
        copy from the current working directory

        Examples
        --------
        >>> # copies dir/file to {workspace}/file
        >>> cmdr.cp('dir/file') # doctest: +SKIP
        """
        path = Path(src)

        if not path.exists():
            raise CommanderException(f"Missing {src} file. Add it and try again.")

        # convert to absolute to ensure we delete the right file on __exit__
        dst = Path(self.workspace, path.name).resolve()
        self._to_delete.append(dst)

        _delete(dst)

        if path.is_file():
            shutil.copy(src, dst)
        else:
            shutil.copytree(src, dst)

    def append_inline(self, line, dst):
        """Append line to a file

        Parameters
        ----------
        line : str
            Line to append

        dst : str
            File to append (can be outside the workspace)

        Examples
        --------
        >>> cmdr.append_inline('*.csv', '.gitignore') # doctest: +SKIP
        """
        if not Path(dst).exists():
            Path(dst).touch()

        original = Path(dst).read_text()
        Path(dst).write_text(original + "\n" + line + "\n")

    def print(self, line):
        """Print message (no color)"""
        self.tw.write(f"{line}\n")

    def success(self, line=None):
        """Print success message (green)"""
        self.tw.sep("=", line, green=True)

    def info(self, line=None):
        """Print information message (blue)"""
        self.tw.sep("=", line, blue=True)

    def warn(self, line=None):
        """Print warning (yellow)"""
        self.tw.sep("=", line, yellow=True)

    def warn_on_exit(self, line):
        """Append a warning message to be displayed on exit"""
        self._warnings.append(line)

    def _warn_show(self):
        """Display accumulated warning messages (added via .warn_on_exit)"""
        if self._warnings:
            self.tw.sep("=", "Warnings", yellow=True)
            self.tw.write("\n\n".join(self._warnings) + "\n")
            self.tw.sep("=", yellow=True)
